Skip to content

11 类型体操顺口溜

TypeScript 类型编程难么?

难。不然怎么会被叫做类型体操呢。

但其实类型体操是有套路的,我把类型体操的各种套路总结成了一个顺口溜:

类型体操顺口溜

模式匹配做提取,重新构造做变换。

递归复用做循环,数组长度做计数。

联合分散可简化,特殊特性要记清。

基础扎实套路熟,类型体操可通关。

逐句解释下:

模式匹配做提取

就像字符串可以通过正则提取子串一样,TypeScript 的类型也可以通过匹配一个模式类型来提取部分类型到 infer 声明的局部变量中返回。

比如提取函数类型的返回值类型:

ts
type GetReturnType<Func extends Function> = Func extends (...args: any[]) => infer ReturnType ? ReturnType : never;

image

重新构造做变换

TypeScript 类型系统可以通过 type 声明类型变量,通过 infer 声明局部变量,类型参数在类型编程中也相当于局部变量,但是它们都不能做修改,想要对类型做变换只能构造一个新的类型,在构造的过程中做过滤和转换。

在字符串、数组、函数、索引等类型都有很多应用,特别是索引类型。

比如把索引变为大写:

ts
type UppercaseKey<Obj extends Record<string, any>> = {
  [Key in keyof Obj as Uppercase<Key & string>]: Obj[Key];
};

image

递归复用做循环

在 TypeScript 类型编程中,遇到数量不确定问题时,就要条件反射的想到递归,每次只处理一个类型,剩下的放到下次递归,直到满足结束条件,就处理完了所有的类型。

比如把长度不确定的字符串转为联合类型:

ts
type StringToUnion<Str extends string> = Str extends `${infer First}${infer Rest}`
  ? First | StringToUnion<Rest>
  : never;

image

数组长度做计数

TypeScript 类型系统没有加减乘除运算符,但是可以构造不同的数组再取 length 来得到相应的结果。这样就把数值运算转为了数组类型的构造和提取。

比如实现减法:

ts
type BuildArray<Length extends number, Ele = unknown, Arr extends unknown[] = []> = Arr["length"] extends Length
  ? Arr
  : BuildArray<Length, Ele, [...Arr, Ele]>;

type Subtract<Num1 extends number, Num2 extends number> = BuildArray<Num1> extends [
  ...arr1: BuildArray<Num2>,
  ...arr2: infer Rest
]
  ? Rest["length"]
  : never;

image

联合分散可简化

TypeScript 对联合类型做了特殊处理,当遇到字符串类型或者作为类型参数出现在条件类型左边的时候,会分散成单个的类型传入做计算,最后把计算结果合并为联合类型。

ts
type UppercaseA<Item extends string> = Item extends "a" ? Uppercase<Item> : Item;

image

这样虽然简化了类型编程,但也带来了一些认知负担。

比如联合类型的判断是这样的:

ts
type IsUnion<A, B = A> = A extends A ? ([B] extends [A] ? false : true) : never;

联合类型做为类型参数直接出现在条件类型左边的时候就会触发 distributive 特性,而不是直接出现在左边的时候不会。

所以,A 是单个类型、B 是整个联合类型。通过比较 A 和 B 来判断联合类型。

特殊特性要记清

会了提取、构造、递归、数组长度计数、联合类型分散这 5 个套路以后,各种类型体操都能写,但是有一些特殊类型的判断需要根据它的特性来,所以要重点记一下这些特性。

比如 any 和任何类型的交叉都为 any,可以用来判断 any 类型:

ts
type IsAny<T> = "dong" extends "guang" & T ? true : false;

image

比如索引一般是 string,而可索引签名不是,可以根据这个来过滤掉可索引签名:

ts
type RemoveIndexSignature<Obj extends Record<string, any>> = {
  [Key in keyof Obj as Key extends `${infer Str}` ? Str : never]: Obj[Key];
};

image

基础扎实套路熟,类型体操可通关

基础指的是 TypeScript 类型系统中的各种类型,以及可以对它们做的各种类型运算逻辑,这是类型编程的原材料。

但是只是会了基础不懂一些套路也很难做好类型编程,所以要熟悉上面 6 种套路。

基础扎实、套路也熟了之后,各种类型编程问题都可以搞定,也就是 "通关"。

练练手

在讲 "TypeScript 类型编程为什么被叫做类型体操 " 的时候我举了一个 ParseQueryString 的类型例子,用来说明类型编程的复杂度。

image

学完了所有套路之后,我们来实现下这个类型:

ParseQueryString

a=1&b=2&c=3&d=4,这样的字符串明显是 query param 个数不确定的,遇到数量不确定的问题,条件反射的就要想到递归:

递归解析出每一个 query params,也就是 & 分隔的每个字符串,每个字符串单独去解析,构造成索引类型,最后把这些所有的单个索引类型合并就行。

也就是这样的:

image

第一步并不知道有多少个 a=1、b=2 这种 query param,要递归的做模式匹配来提取。

然后每一个 query param 再通过模式匹配取出 key 和 value,构造成索引类型。

然后把每个索引类型合并成一个大的索引类型就可以了。

思路理清了,我们一步步来实现下。

首先,要递归的提取 & 分隔的 query param:

image

ts
type ParseQueryString<Str extends string> = Str extends `${infer Param}&${infer Rest}`
  ? MergeParams<ParseParam<Param>, ParseQueryString<Rest>>
  : ParseParam<Str>;

类型参数 Str 为待处理的 query 字符串,通过 extends 约束为 string 类型。

提取 & 分割的字符串到 infer 声明的局部变量 Param 里,后面的字符串放到 Rest 里。

通过 ParseParam 来处理单个的 query param,剩下 query 字符串也是一样的递归处理,然后把这些处理结果合并到一起,也就是 MergeParams。

当提取不出 & 分割的字符串时递归结束,把剩下的字符串也用 ParseParam 来处理。

ParseParam 的实现就是提取和构造:

image

ts
type ParseParam<Param extends string> = Param extends `${infer Key}=${infer Value}`
  ? {
      [K in Key]: Value;
    }
  : {};

类型参数 Param 类单个的 query param,比如 a=1 这种。

通过模式匹配提取 key 和 value 到 infer 声明的局部变量 Key、Value 里。

通过映射类型语法构造成索引类型返回:

image

试一下

每个 query param 处理完了,最后把这一系列构造出的索引类型合并成一个就行了:

image

这也是构造索引类型:

ts
type MergeParams<OneParam extends Record<string, any>, OtherParam extends Record<string, any>> = {
  [Key in keyof OneParam | keyof OtherParam]: Key extends keyof OneParam
    ? Key extends keyof OtherParam
      ? MergeValues<OneParam[Key], OtherParam[Key]>
      : OneParam[Key]
    : Key extends keyof OtherParam
    ? OtherParam[Key]
    : never;
};

类型参数 OneParam、OtherParam 是要合并的 query param,约束为索引类型(索引为 string,索引值为任意类型。

构造一个新的索引类型返回,索引来自两个的合并,也就是 Key in keyof OneParam | keyof OtherParam。

值也要做合并:

如果两个索引类型中都有,那就合并成一个,也就是 MergeValues<OneParam[Key], OtherParam[Key]>。

否则,如果是 OneParam 中的,就取 OneParam[Key],如果是 OtherParam 中的,就取 OtherParam[Key]。

MegeValues 的合并逻辑就是如果两个值是同一个就返回一个,否则构造一个数组类型来合并:

ts
type MergeValues<One, Other> = One extends Other ? One : Other extends unknown[] ? [One, ...Other] : [One, Other];

类型参数 One、Other 是要合并的两个值。

如果两者是同一个类型,也就是 One extends Other,就返回任意一个。

否则,如果是数组就做数组合并,否则构造一个数组把两个类型放进去。

我们单独测试下索引合并:

image

试一下

每个 query param 的解析和构造索引类型,多个索引类型的合并都实现了,合并起来也就实现了 query string 的解析:

ts
type ParseQueryString<Str extends string> = Str extends `${infer Param}&${infer Rest}`
  ? MergeParams<ParseParam<Param>, ParseQueryString<Rest>>
  : ParseParam<Str>;

image

试一下

在实现 ParseQueryString 的类型的时候,我们大量用到了 模式匹配做提取重新构造做变换递归复用做循环 这 3 大套路,思路理清之后利用这些套路能够很顺畅的把这个高级类型写出来。

这是最开始被我用来说明类型编程复杂度的例子,是有一定复杂度的,而学到这我们也能实现了。

再回到最开始的问题:

TypeScript 类型编程难么?

其实熟悉一些套路以后,也没那么难。

总结

为了方便记忆,我总结了类型体操顺口溜,然后分别解释了每句话的含义,之后又做了一个类型体操来练手。

那个最开始被我用来说明 TypeScript 类型编程复杂度的例子,现在我们也能顺畅的实现了,所用的就是类型体操顺口溜中的套路。

这就像武功秘籍一样,理解了每句话的含义,反复修炼,就能成为类型体操的武林高手:

模式匹配做提取,重新构造做变换。

递归复用做循环,数组长度做计数。

联合分散可简化,特殊特性要记清。

基础扎实套路熟,类型体操可通关。

本文案例的合并

我们来看一下这个 Res 类型的 AST:

image

它有类型参数部分(typeParameters),和具体的类型计算逻辑部分(typeAnnotation),右边的  Param extends 1 ? number : string;  是一个 condition 语句,有 Params 和 1 分别对应 checkType、extendsType,number 和 string 则分别对应 trueType、falseType。

我们只需要对传入的 Param 判断下是否是 1,就可以求出具体的类型是 trueType 还是 falseType。

具体类型传参的逻辑和上面一样,就不赘述了,我们看一下根据类型参数求值的逻辑:

ts
function typeEval(node, params) {
  let checkType;
  // 如果参数是泛型,则从传入的参数取值
  if (node.checkType.type === "TSTypeReference") {
    checkType = params[node.checkType.typeName.name];
  } else {
    // 否则直接取字面量参数
    checkType = resolveType(node.checkType);
  }
  const extendsType = resolveType(node.extendsType);
  if (checkType === extendsType || checkType instanceof extendsType) {
    // 如果 extends 逻辑成立
    return resolveType(node.trueType);
  } else {
    return resolveType(node.falseType);
  }
}

这样,我们就可以求出这个 Res 的高级类型当传入 Params 为 1 时求出的最终类型。

有了最终类型之后,就和直接传入具体类型的函数调用的类型检查一样了。(上面我们实现过)

执行一下,效果如下:

image

完整代码如下(有些长,可以先跳过往后看):

ts
const {declare} = require("@babel/helper-plugin-utils");

// 解析高级类型的值,传入泛型参数的值
function typeEval(node, params) {
  let checkType;
  if (node.checkType.type === "TSTypeReference") {
    checkType = params[node.checkType.typeName.name];
  } else {
    checkType = resolveType(node.checkType);
  }
  const extendsType = resolveType(node.extendsType);
  // 如果 condition 表达式 的 check 部分为 true,则返回 trueType,否则返回 falseType
  if (checkType === extendsType || checkType instanceof extendsType) {
    return resolveType(node.trueType);
  } else {
    return resolveType(node.falseType);
  }
}

function resolveType(targetType, referenceTypesMap = {}, scope) {
  const tsTypeAnnotationMap = {
    TSStringKeyword: "string",
    TSNumberKeyword: "number",
  };
  switch (targetType.type) {
    case "TSTypeAnnotation":
      if (targetType.typeAnnotation.type === "TSTypeReference") {
        return referenceTypesMap[targetType.typeAnnotation.typeName.name];
      }
      return tsTypeAnnotationMap[targetType.typeAnnotation.type];
    case "NumberTypeAnnotation":
      return "number";
    case "StringTypeAnnotation":
      return "string";
    case "TSNumberKeyword":
      return "number";
    case "TSTypeReference":
      const typeAlias = scope.getData(targetType.typeName.name);
      const paramTypes = targetType.typeParameters.params.map((item) => {
        return resolveType(item);
      });
      const params = typeAlias.paramNames.reduce((obj, name, index) => {
        obj[name] = paramTypes[index];
        return obj;
      }, {});
      return typeEval(typeAlias.body, params);
    case "TSLiteralType":
      return targetType.literal.value;
  }
}

function noStackTraceWrapper(cb) {
  const tmp = Error.stackTraceLimit;
  Error.stackTraceLimit = 0;
  cb && cb(Error);
  Error.stackTraceLimit = tmp;
}

const noFuncAssignLint = declare((api, options, dirname) => {
  api.assertVersion(7);

  return {
    pre(file) {
      file.set("errors", []);
    },
    visitor: {
      TSTypeAliasDeclaration(path) {
        path.scope.setData(path.get("id").toString(), {
          paramNames: path.node.typeParameters.params.map((item) => {
            return item.name;
          }),
          body: path.getTypeAnnotation(),
        });
        path.scope.setData(path.get("params"));
      },
      CallExpression(path, state) {
        const errors = state.file.get("errors");
        // 泛型参数
        const realTypes = path.node.typeParameters.params.map((item) => {
          return resolveType(item, {}, path.scope);
        });
        // 实参类型
        const argumentsTypes = path.get("arguments").map((item) => {
          return resolveType(item.getTypeAnnotation());
        });
        const calleeName = path.get("callee").toString();
        // 根据函数名查找到函数声明
        const functionDeclarePath = path.scope.getBinding(calleeName).path;
        const realTypeMap = {};
        functionDeclarePath.node.typeParameters.params.map((item, index) => {
          realTypeMap[item.name] = realTypes[index];
        });
        // 把泛型参数传递给具体的泛型
        const declareParamsTypes = functionDeclarePath.get("params").map((item) => {
          return resolveType(item.getTypeAnnotation(), realTypeMap);
        });

        // 声明类型和具体的类型的对比(类型检查)
        argumentsTypes.forEach((item, index) => {
          if (item !== declareParamsTypes[index]) {
            noStackTraceWrapper((Error) => {
              errors.push(
                path
                  .get("arguments." + index)
                  .buildCodeFrameError(`${item} can not assign to ${declareParamsTypes[index]}`, Error)
              );
            });
          }
        });
      },
    },
    post(file) {
      console.log(file.get("errors"));
    },
  };
});

module.exports = noFuncAssignLint;

就这样,我们实现了 typescript 高级类型!

总结

类型代表了变量的内容和能对它进行的操作,静态类型让检查可以在编译期间做,随着前端项目越来越重,越来越需要 typescript 这类静态类型语言。

类型检查就是做 AST 的对比,判断声明的和实际的是否一致:

  • 简单类型就直接对比,相当于 if else
  • 带泛型的要先把类型参数传递过去才能确定类型,之后对比,相当于函数调用包裹 if else
  • 带高级类型的泛型的类型检查,多了一个对类型求值的过程,相当于多级函数调用之后再判断 if else

实现一个完整的 typescript type cheker 还是很复杂的,不然 typescript checker 部分的代码也不至于好几万行了。但是思路其实没有那么难,按照我们文中的思路来,是可以实现一个完整的 type checker 的。

这一节主要是用到了 path.getTypeAnnotation 的 api 来获取声明的类型,然后进行 AST 的检查,希望能够帮助你理解 type checker 的实现原理。

(当然,文中只是实现了独立的一个个类型的检查,tsc 会递归地做多个文件的全文的类型检查,但是具体的每一部分都是类似的思路。)

(代码在 这里,建议 git clone 下来通过 node 跑一下)